iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 24 篇

Day-024. 一些讓你看來很強的全端 TRPC 伴讀 -TRPC with App router

  • 分享至 

  • xImage
  •  

先前的範例都是在 NextJS 的 page router ,筆者一開始以為沒有支援 app router 的使用,但仔細深入研究後 TRPC 對於 app router 的使用上支援度蠻高的,今天就來簡單 demo 一下內容~

install package

起一個 next 專案

> npx create-next-app@latest trpc_server_action

安裝 package

// package.json
"dependencies": {
    "@prisma/client": "^5.3.1",
    "@tanstack/react-query": "^4.35.3",
    "@tanstack/react-query-devtools": "^4.35.3",
    "@trpc/client": "^10.38.3",
    "@trpc/react-query": "^10.38.3",
    "@trpc/server": "^10.38.3",
    "@types/node": "20.6.3",
    "@types/react": "18.2.22",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.16",
    "eslint": "8.49.0",
    "eslint-config-next": "13.5.2",
    "next": "13.5.2",
    "postcss": "8.4.30",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.3",
    "typescript": "5.2.2",
    "zod": "^3.22.2"
  }

初始化 db

init prisma

> npx prisma init --datasource-provider sqlite 
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  checked Boolean @default(false)
}

migrate schema

> npx prisma migrate dev "message"

生成 prisma client

> npx prisma generate    

初始化 db instance

// ~src?db/index,ts
import { PrismaClient } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library";

declare global {
  var prisma:
    | PrismaClient
    | undefined
}

export const prisma = global.prisma || new PrismaClient(),
})

if (process.env.NODE_ENV !== 'production') global.prisma = prisma

server api

這邊跟之前一樣如果讀者還不理解 trpc server 內容可以看之前的內容~ Day 6

//~src/server/trpc.ts
import { inferRouterInputs, inferRouterOutputs, initTRPC } from '@trpc/server'

import { prisma } from '@/db'
import { AppRouter } from '.'
export const createTRPCContext = async () => {
  return {
    prisma
  }
}
const t = initTRPC.context<typeof createTRPCContext>().create()
export const router = t.router

export const publicProcedure = t.procedure

export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;

CRUD api

//~src/server/post.ts
import { z } from "zod";
import { router, publicProcedure, createTRPCContext } from "./trpc";
import { TRPCError } from "@trpc/server";

export const postsRouter = router({
  getPosts:
    publicProcedure
      .query(async ({ ctx }) => {
        const { prisma } = ctx
        const posts = await prisma.post.findMany({})
        return posts
      }),
  createPosts:
    publicProcedure
      .input(z.object({
        title: z.string(),
      }))
      .mutation(async ({ ctx, input }) => {
        const { title } = input
        const { prisma } = ctx
        if (!title) {
          throw new TRPCError({ code: 'BAD_REQUEST', message: 'title is require' })
        }
        const newTitle = await prisma.post.create({
          data: {
            title
          }
        })
      }),
  deletePosts:
    publicProcedure
      .input(z.object({
        id: z.number(),
      }))
      .mutation(async ({ ctx, input }) => {
        const { id } = input
        const { prisma } = ctx
        await prisma.post.delete({
          where: {
            id
          }
        })
      }),
  togglePost:
    publicProcedure
      .input(z.object({
        id: z.number(),
        checked: z.boolean()
      }))
      .mutation(async ({ ctx, input }) => {
        const { id, checked } = input
        const { prisma } = ctx
        await prisma.post.update({
          where: {
            id,
          },
          data: {
            checked
          }
        })
      }),
})

app router

import { postsRouter } from "./posts";
import { router, publicProcedure, createTRPCContext } from "./trpc";

export const appRouter = router({
  posts: postsRouter
})
export const appCaller = appRouter.createCaller(await createTRPCContext())

export type AppRouter = typeof appRouter

api router

// app/api/trpc/[trpc]/route.ts
import { appRouter } from '@/server';
import { createTRPCContext } from '@/server/trpc';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: createTRPCContext
  });
export { handler as GET, handler as POST };

這邊可以順便比較一下 page 寫法跟 app 寫法有什麼差異。

import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createContext } from '../../../server/trpc/context';
import { appRouter } from '../../../server/trpc/router/_app';
// @see https://nextjs.org/docs/api-routes/introduction
export default createNextApiHandler({
  router: appRouter,
  createContext,
});

簡單測試一下 api ,看來是沒問題~

https://ithelp.ithome.com.tw/upload/images/20231008/20145677iXZJjHdDNt.png

TRPC client

透過 createTRPCReact create TRPC client 實例 ,在 app router 中我們是透過 createTRPCReact 起一個 trpc instance。


// ~src/app/client
import { AppRouter, appCaller } from "@/server";
import { createTRPCReact } from "@trpc/react-query";


export const trpc = createTRPCReact<AppRouter>({
  overrides: {
    useMutation: {
      async onSuccess(opt) {
        // Calls the `onSuccess` defined in the `useMutation()`-options:
        await opt.originalFn()
        // Invalidate all queries in the react-query cache:
        opt.queryClient.invalidateQueries()
      }
    }
  }
});

privider

這邊值得注意的是為了讓 trpc 跟 react-query 成功整合一起,會需要包一個 provider 的 wrapper 告訴 TRPC 要對應到哪個 react-query 的 client,那因為 app router 預設是 server component ,所以記得在最上面加 'use client' 。

// ~src/app/provider.tsx
'use client'
import { trpc } from '@/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import React, { PropsWithChildren, useState } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryClient = new QueryClient()
function Provider({ children }: PropsWithChildren) {
  const [trpcClient, setTrpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        })
      ]
    })
  )
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
        <ReactQueryDevtools initialIsOpen />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default Provider

之後把 provider 包到 layout 中。

//~src/app/layout.tsx

import Provider from './provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {

  return (
    <html lang="en">
      <body className={inter.className}>
        <Provider>
          {children}
        </Provider>
      </body>
    </html>
  )
}

讀者可能還記得之前用 page router 時其實沒有加 trpc.Provide ,那是因為在 page router 中我們是用 createTRPCNext ,createTRPCNext 是 page only 用法。

queryClient 共享只需要套到 createTRPCNext instance 中。

import { queryClient } from '@/components/Provider';

export const api = createTRPCNext<AppRouter>({
  config(opts) {
    return {
      links: [
        httpBatchLink({
          url: `http://localhost:3000/api/trpc`,
          // You can pass any HTTP headers you wish here
          async headers() {
            return {
            };
          },
        }),
      ],
      queryClient,
    };
  },
  /**
   * @link https://trpc.io/docs/ssr
   **/
  ssr: false,
});

透過 api.withTRPC 綁定到 page 中,

import { api } from '@/utils/api'
import type { AppProps } from 'next/app'
import "@/styles/globals.css";
import { Provider } from '@/components/Provider';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider>
      <Component {...pageProps} />
    </Provider>
  )

}

export default api.withTRPC(MyApp);

Provider 部分也只需要跟 react query 平常寫法就 ``

export const Provider = ({ children }: PropsWithChildren) => {
  return (
      <QueryClientProvider client={queryClient}>
        {children}
        <ReactQueryDevtools initialIsOpen={false} />

      </QueryClientProvider>
  )
}

接著我們來接 api 吧~ 先在 page.tsx 中添加 TodoList components

// ~src/app/page.tsx
import TodoList from '@/components/TodoList'

export default async function Home() {
  return (
    <div>
      <h1>Post Lists</h1>
      <TodoList initialData={initialData} />
    </div>
  )
}

整個 TodoList 大致長這樣

// TodoList.tsx
'use client'
import { trpc } from '@/client'
import { Post } from '@prisma/client'
import React, { useState } from 'react'
interface TodoList {
}
function TodoList({  }: TodoList) {
  const [title, setTitle] = useState<string>('')
  const { data: todos, refetch } = trpc.posts.getPosts.useQuery()
  const { mutateAsync: createTodo } = trpc.posts.createPosts.useMutation()
  const { mutateAsync: deletePost } = trpc.posts.deletePosts.useMutation()
  const { mutateAsync: togglePost } = trpc.posts.togglePost.useMutation()

  return (
    <div>
      <input type="text" value={title} onChange={e => setTitle(e.target.value)} className='text-black' />
      <button onClick={() => {
        createTodo({ title })
      }}>add post</button>
      <div className='w-[200px] flex flex-col gap-[1rem]'>
        {todos?.map(todo => (
          <div className='flex justify-between items-center ' key={todo.id}>
            <input
              type="checkbox"
              checked={!!todo.checked}
              onChange={e => {
                togglePost({ id: todo.id, checked: e.target.checked })
              }}
            />
            <p>{todo.title}</p>
            <button
              className='cursor-pointer bg-red-500 p-2'
              onClick={() => {
                deletePost({ id: todo.id })
              }}
            >delete</button>
          </div>
        ))}
      </div>
    </div>
  )
}

export default TodoList

那為了確保每次的 crud 頁面都是及時的需要加上 invalidate 機制,概念就是說,所有的 useMutate 只要 onSuccess 就 update client 專的所有 data cache。

abortOnUnmount 則是只要 component unmount 就取消 fetch。

export const trpc = createTRPCReact<AppRouter>({
  abortOnUnmount: true,
  overrides: {
    useMutation: {
      async onSuccess(opt) {
        // Calls the `onSuccess` defined in the `useMutation()`-options:
        await opt.originalFn()
        // Invalidate all queries in the react-query cache:
        opt.queryClient.invalidateQueries()
      }
    }
  }
});

如此畫面就會絲滑很多~


但這時會出現一個問題就是只要重新整理頁面,畫面就會一閃一閃的,這也是因為 ssr 的關係,因為我們的 inital html 不能有包含 post 的所有資料,而是透過 client 去 fetch ,為了解決這個問題我們需要用到 initialData 。

initialData

那為了拿到 initialData 這邊會透過 trpc 的 caler ,如果讀者還記得的話前天的 test 就是透過 caller 呼叫,因為 app router 中的 server component 不能使用 useQuery 或是 useState 的 client component 用法,所以只能用 caller 的方式。

使用方式在為讀者複習一下只需要把 appRouter 呼叫 createCaller ,然後把 context 帶進去就可以了~

// ~src/server/index.ts
export const appCaller = appRouter.createCaller(await createTRPCContext())

然後再到 home page call getPosts 傳到 TodoList props 中

// ~src/app/page.tsx
export default async function Home() {
  const initialData = await appCaller.posts.getPosts()
  return (
    <div>
      <h1>Post Lists</h1>
      <TodoList initialData={initialData} />
    </div>
  )
}

接著把 initialData 放到 useQuery 的第二個參數。

interface TodoList {
  initialData: Post[]
}
function TodoList({ initialData }: TodoList) {
  const [title, setTitle] = useState<string>('')
  const utils = trpc.useContext()
  const { data: todos, refetch } = trpc.posts.getPosts.useQuery(undefined, {
    initialData
  })
  
  //..

如此一來重新整理後畫面就不會一直閃了,透過 initialdata 方式提前把 data 放到 initial html 中,來優化白頁問題。

相關連結

https://github.com/Danny101201/next13_trpc/tree/main


上一篇
Day-023. 一些讓你看來很強的全端 TRPC 伴讀 -trpc test
下一篇
Day-025. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization ( Init )
系列文
一些讓你看來很強的全端- trcp 伴讀 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言